// Copyright (C) 2012 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package com.google.gerrit.server.change;

import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.stream.Collectors.groupingBy;
import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toList;
import static java.util.stream.Collectors.toSet;
import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;

import com.google.auto.value.AutoValue;
import com.google.common.base.MoreObjects;
import com.google.common.base.Strings;
import com.google.common.collect.ListMultimap;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Ordering;
import com.google.common.hash.HashCode;
import com.google.common.hash.Hashing;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.TimeUtil;
import com.google.gerrit.common.data.LabelType;
import com.google.gerrit.common.data.LabelTypes;
import com.google.gerrit.extensions.api.changes.AddReviewerInput;
import com.google.gerrit.extensions.api.changes.AddReviewerResult;
import com.google.gerrit.extensions.api.changes.NotifyHandling;
import com.google.gerrit.extensions.api.changes.RecipientType;
import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
import com.google.gerrit.extensions.api.changes.ReviewResult;
import com.google.gerrit.extensions.api.changes.ReviewerInfo;
import com.google.gerrit.extensions.client.Comment.Range;
import com.google.gerrit.extensions.client.ReviewerState;
import com.google.gerrit.extensions.client.Side;
import com.google.gerrit.extensions.common.AccountInfo;
import com.google.gerrit.extensions.common.FixReplacementInfo;
import com.google.gerrit.extensions.common.FixSuggestionInfo;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.Response;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
import com.google.gerrit.extensions.restapi.Url;
import com.google.gerrit.reviewdb.client.Account;
import com.google.gerrit.reviewdb.client.Change;
import com.google.gerrit.reviewdb.client.ChangeMessage;
import com.google.gerrit.reviewdb.client.Comment;
import com.google.gerrit.reviewdb.client.FixReplacement;
import com.google.gerrit.reviewdb.client.FixSuggestion;
import com.google.gerrit.reviewdb.client.LabelId;
import com.google.gerrit.reviewdb.client.Patch;
import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
import com.google.gerrit.reviewdb.client.PatchSet;
import com.google.gerrit.reviewdb.client.PatchSetApproval;
import com.google.gerrit.reviewdb.client.RobotComment;
import com.google.gerrit.reviewdb.server.ReviewDb;
import com.google.gerrit.server.ApprovalsUtil;
import com.google.gerrit.server.ChangeMessagesUtil;
import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.CommentsUtil;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.OutputFormat;
import com.google.gerrit.server.PatchSetUtil;
import com.google.gerrit.server.ReviewerSet;
import com.google.gerrit.server.account.AccountsCollection;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.extensions.events.CommentAdded;
import com.google.gerrit.server.mail.Address;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.notedb.ChangeUpdate;
import com.google.gerrit.server.notedb.NotesMigration;
import com.google.gerrit.server.patch.PatchListCache;
import com.google.gerrit.server.permissions.ChangePermission;
import com.google.gerrit.server.permissions.LabelPermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.project.ChangeControl;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.BatchUpdateOp;
import com.google.gerrit.server.update.ChangeContext;
import com.google.gerrit.server.update.Context;
import com.google.gerrit.server.update.RetryHelper;
import com.google.gerrit.server.update.RetryingRestModifyView;
import com.google.gerrit.server.update.UpdateException;
import com.google.gerrit.server.util.LabelVote;
import com.google.gson.Gson;
import com.google.gwtorm.server.OrmException;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.OptionalInt;
import java.util.Set;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.Config;

@Singleton
public class PostReview
    extends RetryingRestModifyView<RevisionResource, ReviewInput, Response<ReviewResult>> {
  public static final String ERROR_ADDING_REVIEWER = "error adding reviewer";
  public static final String ERROR_ONLY_OWNER_CAN_MODIFY_WORK_IN_PROGRESS =
      "only change owner can specify work_in_progress or ready";
  public static final String ERROR_WIP_READY_MUTUALLY_EXCLUSIVE =
      "work_in_progress and ready are mutually exclusive";

  public static final String START_REVIEW_MESSAGE = "This change is ready for review.";

  private static final Gson GSON = OutputFormat.JSON_COMPACT.newGson();
  private static final int DEFAULT_ROBOT_COMMENT_SIZE_LIMIT_IN_BYTES = 1024 * 1024;

  private final Provider<ReviewDb> db;
  private final ChangesCollection changes;
  private final ChangeData.Factory changeDataFactory;
  private final ApprovalsUtil approvalsUtil;
  private final ChangeMessagesUtil cmUtil;
  private final CommentsUtil commentsUtil;
  private final PatchSetUtil psUtil;
  private final PatchListCache patchListCache;
  private final AccountsCollection accounts;
  private final EmailReviewComments.Factory email;
  private final CommentAdded commentAdded;
  private final PostReviewers postReviewers;
  private final NotesMigration migration;
  private final NotifyUtil notifyUtil;
  private final Config gerritConfig;
  private final WorkInProgressOp.Factory workInProgressOpFactory;

  @Inject
  PostReview(
      Provider<ReviewDb> db,
      RetryHelper retryHelper,
      ChangesCollection changes,
      ChangeData.Factory changeDataFactory,
      ApprovalsUtil approvalsUtil,
      ChangeMessagesUtil cmUtil,
      CommentsUtil commentsUtil,
      PatchSetUtil psUtil,
      PatchListCache patchListCache,
      AccountsCollection accounts,
      EmailReviewComments.Factory email,
      CommentAdded commentAdded,
      PostReviewers postReviewers,
      NotesMigration migration,
      NotifyUtil notifyUtil,
      @GerritServerConfig Config gerritConfig,
      WorkInProgressOp.Factory workInProgressOpFactory) {
    super(retryHelper);
    this.db = db;
    this.changes = changes;
    this.changeDataFactory = changeDataFactory;
    this.commentsUtil = commentsUtil;
    this.psUtil = psUtil;
    this.patchListCache = patchListCache;
    this.approvalsUtil = approvalsUtil;
    this.cmUtil = cmUtil;
    this.accounts = accounts;
    this.email = email;
    this.commentAdded = commentAdded;
    this.postReviewers = postReviewers;
    this.migration = migration;
    this.notifyUtil = notifyUtil;
    this.gerritConfig = gerritConfig;
    this.workInProgressOpFactory = workInProgressOpFactory;
  }

  @Override
  protected Response<ReviewResult> applyImpl(
      BatchUpdate.Factory updateFactory, RevisionResource revision, ReviewInput input)
      throws RestApiException, UpdateException, OrmException, IOException,
          PermissionBackendException, ConfigInvalidException {
    return apply(updateFactory, revision, input, TimeUtil.nowTs());
  }

  public Response<ReviewResult> apply(
      BatchUpdate.Factory updateFactory, RevisionResource revision, ReviewInput input, Timestamp ts)
      throws RestApiException, UpdateException, OrmException, IOException,
          PermissionBackendException, ConfigInvalidException {
    // Respect timestamp, but truncate at change created-on time.
    ts = Ordering.natural().max(ts, revision.getChange().getCreatedOn());
    if (revision.getEdit().isPresent()) {
      throw new ResourceConflictException("cannot post review on edit");
    }
    if (input.onBehalfOf != null) {
      revision = onBehalfOf(revision, input);
    } else if (input.drafts == null) {
      input.drafts = DraftHandling.DELETE;
    }
    if (input.labels != null) {
      checkLabels(revision, input.strictLabels, input.labels);
    }
    if (input.comments != null) {
      cleanUpComments(input.comments);
      checkComments(revision, input.comments);
    }
    if (input.robotComments != null) {
      if (!migration.readChanges()) {
        throw new MethodNotAllowedException("robot comments not supported");
      }
      checkRobotComments(revision, input.robotComments);
    }

    NotifyHandling reviewerNotify = input.notify;
    if (input.notify == null) {
      input.notify = defaultNotify(revision.getChange(), input);
    }

    ListMultimap<RecipientType, Account.Id> accountsToNotify =
        notifyUtil.resolveAccounts(input.notifyDetails);

    Map<String, AddReviewerResult> reviewerJsonResults = null;
    List<PostReviewers.Addition> reviewerResults = Lists.newArrayList();
    boolean hasError = false;
    boolean confirm = false;
    if (input.reviewers != null) {
      reviewerJsonResults = Maps.newHashMap();
      for (AddReviewerInput reviewerInput : input.reviewers) {
        // Prevent notifications because setting reviewers is batched.
        reviewerInput.notify = NotifyHandling.NONE;

        PostReviewers.Addition result =
            postReviewers.prepareApplication(revision.getChangeResource(), reviewerInput, true);
        reviewerJsonResults.put(reviewerInput.reviewer, result.result);
        if (result.result.error != null) {
          hasError = true;
          continue;
        }
        if (result.result.confirm != null) {
          confirm = true;
          continue;
        }
        reviewerResults.add(result);
      }
    }

    ReviewResult output = new ReviewResult();
    output.reviewers = reviewerJsonResults;
    if (hasError || confirm) {
      output.error = ERROR_ADDING_REVIEWER;
      return Response.withStatusCode(SC_BAD_REQUEST, output);
    }
    output.labels = input.labels;

    try (BatchUpdate bu =
        updateFactory.create(db.get(), revision.getChange().getProject(), revision.getUser(), ts)) {
      Account.Id id = revision.getUser().getAccountId();
      boolean ccOrReviewer = false;
      if (input.labels != null && !input.labels.isEmpty()) {
        ccOrReviewer = input.labels.values().stream().filter(v -> v != 0).findFirst().isPresent();
      }

      if (!ccOrReviewer) {
        // Check if user was already CCed or reviewing prior to this review.
        ReviewerSet currentReviewers =
            approvalsUtil.getReviewers(db.get(), revision.getChangeResource().getNotes());
        ccOrReviewer = currentReviewers.all().contains(id);
      }

      // Apply reviewer changes first. Revision emails should be sent to the
      // updated set of reviewers. Also keep track of whether the user added
      // themselves as a reviewer or to the CC list.
      for (PostReviewers.Addition reviewerResult : reviewerResults) {
        bu.addOp(revision.getChange().getId(), reviewerResult.op);
        if (!ccOrReviewer && reviewerResult.result.reviewers != null) {
          for (ReviewerInfo reviewerInfo : reviewerResult.result.reviewers) {
            if (Objects.equals(id.get(), reviewerInfo._accountId)) {
              ccOrReviewer = true;
              break;
            }
          }
        }
        if (!ccOrReviewer && reviewerResult.result.ccs != null) {
          for (AccountInfo accountInfo : reviewerResult.result.ccs) {
            if (Objects.equals(id.get(), accountInfo._accountId)) {
              ccOrReviewer = true;
              break;
            }
          }
        }
      }

      if (!ccOrReviewer) {
        // User posting this review isn't currently in the reviewer or CC list,
        // isn't being explicitly added, and isn't voting on any label.
        // Automatically CC them on this change so they receive replies.
        PostReviewers.Addition selfAddition =
            postReviewers.ccCurrentUser(revision.getUser(), revision);
        bu.addOp(revision.getChange().getId(), selfAddition.op);
      }

      // Add WorkInProgressOp if requested.
      if (input.ready || input.workInProgress) {
        if (input.ready && input.workInProgress) {
          output.error = ERROR_WIP_READY_MUTUALLY_EXCLUSIVE;
          return Response.withStatusCode(SC_BAD_REQUEST, output);
        }
        if (!revision.getChange().getOwner().equals(revision.getUser().getAccountId())) {
          output.error = ERROR_ONLY_OWNER_CAN_MODIFY_WORK_IN_PROGRESS;
          return Response.withStatusCode(SC_BAD_REQUEST, output);
        }
        if (input.ready) {
          output.ready = true;
        }

        // Suppress notifications in WorkInProgressOp, we'll take care of
        // them in this endpoint.
        WorkInProgressOp.Input wipIn = new WorkInProgressOp.Input();
        wipIn.notify = NotifyHandling.NONE;
        bu.addOp(
            revision.getChange().getId(),
            workInProgressOpFactory.create(input.workInProgress, wipIn));
      }

      // Add the review op.
      bu.addOp(
          revision.getChange().getId(),
          new Op(revision.getPatchSet().getId(), input, accountsToNotify));

      bu.execute();

      for (PostReviewers.Addition reviewerResult : reviewerResults) {
        reviewerResult.gatherResults();
      }

      emailReviewers(revision.getChange(), reviewerResults, reviewerNotify, accountsToNotify);
    }

    return Response.ok(output);
  }

  private NotifyHandling defaultNotify(Change c, ReviewInput in) {
    boolean workInProgress = c.isWorkInProgress();
    if (in.workInProgress) {
      workInProgress = true;
    }
    if (in.ready) {
      workInProgress = false;
    }

    if (ChangeMessagesUtil.isAutogenerated(in.tag)) {
      // Autogenerated comments default to lower notify levels.
      return workInProgress ? NotifyHandling.OWNER : NotifyHandling.OWNER_REVIEWERS;
    }

    if (workInProgress && !c.hasReviewStarted()) {
      // If review hasn't started we want to minimize recipients, no matter who
      // the author is.
      return NotifyHandling.OWNER;
    }

    return NotifyHandling.ALL;
  }

  private void emailReviewers(
      Change change,
      List<PostReviewers.Addition> reviewerAdditions,
      @Nullable NotifyHandling notify,
      ListMultimap<RecipientType, Account.Id> accountsToNotify) {
    List<Account.Id> to = new ArrayList<>();
    List<Account.Id> cc = new ArrayList<>();
    List<Address> toByEmail = new ArrayList<>();
    List<Address> ccByEmail = new ArrayList<>();
    for (PostReviewers.Addition addition : reviewerAdditions) {
      if (addition.state == ReviewerState.REVIEWER) {
        to.addAll(addition.reviewers);
        toByEmail.addAll(addition.reviewersByEmail);
      } else if (addition.state == ReviewerState.CC) {
        cc.addAll(addition.reviewers);
        ccByEmail.addAll(addition.reviewersByEmail);
      }
    }
    if (reviewerAdditions.size() > 0) {
      reviewerAdditions
          .get(0)
          .op
          .emailReviewers(change, to, cc, toByEmail, ccByEmail, notify, accountsToNotify);
    }
  }

  private RevisionResource onBehalfOf(RevisionResource rev, ReviewInput in)
      throws BadRequestException, AuthException, UnprocessableEntityException, OrmException,
          PermissionBackendException, IOException, ConfigInvalidException {
    if (in.labels == null || in.labels.isEmpty()) {
      throw new AuthException(
          String.format("label required to post review on behalf of \"%s\"", in.onBehalfOf));
    }
    if (in.drafts == null) {
      in.drafts = DraftHandling.KEEP;
    }
    if (in.drafts != DraftHandling.KEEP) {
      throw new AuthException("not allowed to modify other user's drafts");
    }

    CurrentUser caller = rev.getUser();
    PermissionBackend.ForChange perm = rev.permissions().database(db);
    LabelTypes labelTypes = rev.getControl().getLabelTypes();
    Iterator<Map.Entry<String, Short>> itr = in.labels.entrySet().iterator();
    while (itr.hasNext()) {
      Map.Entry<String, Short> ent = itr.next();
      LabelType type = labelTypes.byLabel(ent.getKey());
      if (type == null && in.strictLabels) {
        throw new BadRequestException(
            String.format("label \"%s\" is not a configured label", ent.getKey()));
      } else if (type == null) {
        itr.remove();
        continue;
      }

      if (!caller.isInternalUser()) {
        try {
          perm.check(new LabelPermission.WithValue(ON_BEHALF_OF, type, ent.getValue()));
        } catch (AuthException e) {
          throw new AuthException(
              String.format(
                  "not permitted to modify label \"%s\" on behalf of \"%s\"",
                  type.getName(), in.onBehalfOf));
        }
      }
    }
    if (in.labels.isEmpty()) {
      throw new AuthException(
          String.format("label required to post review on behalf of \"%s\"", in.onBehalfOf));
    }

    IdentifiedUser reviewer = accounts.parseOnBehalfOf(caller, in.onBehalfOf);
    try {
      perm.user(reviewer).check(ChangePermission.READ);
    } catch (AuthException e) {
      throw new UnprocessableEntityException(
          String.format("on_behalf_of account %s cannot see change", reviewer.getAccountId()));
    }

    ChangeControl ctl = rev.getControl().forUser(reviewer);
    return new RevisionResource(changes.parse(ctl), rev.getPatchSet());
  }

  private void checkLabels(RevisionResource rsrc, boolean strict, Map<String, Short> labels)
      throws BadRequestException, AuthException, PermissionBackendException {
    LabelTypes types = rsrc.getControl().getLabelTypes();
    PermissionBackend.ForChange perm = rsrc.permissions();
    Iterator<Map.Entry<String, Short>> itr = labels.entrySet().iterator();
    while (itr.hasNext()) {
      Map.Entry<String, Short> ent = itr.next();
      LabelType lt = types.byLabel(ent.getKey());
      if (lt == null) {
        if (strict) {
          throw new BadRequestException(
              String.format("label \"%s\" is not a configured label", ent.getKey()));
        }
        itr.remove();
        continue;
      }

      if (ent.getValue() == null || ent.getValue() == 0) {
        // Always permit 0, even if it is not within range.
        // Later null/0 will be deleted and revoke the label.
        continue;
      }

      if (lt.getValue(ent.getValue()) == null) {
        if (strict) {
          throw new BadRequestException(
              String.format("label \"%s\": %d is not a valid value", ent.getKey(), ent.getValue()));
        }
        itr.remove();
        continue;
      }

      short val = ent.getValue();
      try {
        perm.check(new LabelPermission.WithValue(lt, val));
      } catch (AuthException e) {
        if (strict) {
          throw new AuthException(
              String.format("Applying label \"%s\": %d is restricted", lt.getName(), val));
        }
        ent.setValue(perm.squashThenCheck(lt, val));
      }
    }
  }

  private static <T extends CommentInput> void cleanUpComments(
      Map<String, List<T>> commentsPerPath) {
    Iterator<List<T>> mapValueIterator = commentsPerPath.values().iterator();
    while (mapValueIterator.hasNext()) {
      List<T> comments = mapValueIterator.next();
      if (comments == null) {
        mapValueIterator.remove();
        continue;
      }

      cleanUpComments(comments);
      if (comments.isEmpty()) {
        mapValueIterator.remove();
      }
    }
  }

  private static <T extends CommentInput> void cleanUpComments(List<T> comments) {
    Iterator<T> commentsIterator = comments.iterator();
    while (commentsIterator.hasNext()) {
      T comment = commentsIterator.next();
      if (comment == null) {
        commentsIterator.remove();
        continue;
      }

      comment.message = Strings.nullToEmpty(comment.message).trim();
      if (comment.message.isEmpty()) {
        commentsIterator.remove();
      }
    }
  }

  private <T extends CommentInput> void checkComments(
      RevisionResource revision, Map<String, List<T>> commentsPerPath)
      throws OrmException, BadRequestException {
    Set<String> revisionFilePaths = getAffectedFilePaths(revision);
    for (Map.Entry<String, List<T>> entry : commentsPerPath.entrySet()) {
      String path = entry.getKey();
      PatchSet.Id patchSetId = revision.getChange().currentPatchSetId();
      ensurePathRefersToAvailableOrMagicFile(path, revisionFilePaths, patchSetId);

      List<T> comments = entry.getValue();
      for (T comment : comments) {
        ensureLineIsNonNegative(comment.line, path);
        ensureCommentNotOnMagicFilesOfAutoMerge(path, comment);
        ensureRangeIsValid(path, comment.range);
      }
    }
  }

  private Set<String> getAffectedFilePaths(RevisionResource revision) throws OrmException {
    ChangeData changeData = changeDataFactory.create(db.get(), revision.getControl());
    return new HashSet<>(changeData.filePaths(revision.getPatchSet()));
  }

  private static void ensurePathRefersToAvailableOrMagicFile(
      String path, Set<String> availableFilePaths, PatchSet.Id patchSetId)
      throws BadRequestException {
    if (!availableFilePaths.contains(path) && !Patch.isMagic(path)) {
      throw new BadRequestException(
          String.format("file %s not found in revision %s", path, patchSetId));
    }
  }

  private static void ensureLineIsNonNegative(Integer line, String path)
      throws BadRequestException {
    if (line != null && line < 0) {
      throw new BadRequestException(
          String.format("negative line number %d not allowed on %s", line, path));
    }
  }

  private static <T extends CommentInput> void ensureCommentNotOnMagicFilesOfAutoMerge(
      String path, T comment) throws BadRequestException {
    if (Patch.isMagic(path) && comment.side == Side.PARENT && comment.parent == null) {
      throw new BadRequestException(String.format("cannot comment on %s on auto-merge", path));
    }
  }

  private void checkRobotComments(
      RevisionResource revision, Map<String, List<RobotCommentInput>> in)
      throws BadRequestException, OrmException {
    cleanUpComments(in);
    for (Map.Entry<String, List<RobotCommentInput>> e : in.entrySet()) {
      String commentPath = e.getKey();
      for (RobotCommentInput c : e.getValue()) {
        ensureSizeOfJsonInputIsWithinBounds(c);
        ensureRobotIdIsSet(c.robotId, commentPath);
        ensureRobotRunIdIsSet(c.robotRunId, commentPath);
        ensureFixSuggestionsAreAddable(c.fixSuggestions, commentPath);
      }
    }
    checkComments(revision, in);
  }

  private void ensureSizeOfJsonInputIsWithinBounds(RobotCommentInput robotCommentInput)
      throws BadRequestException {
    OptionalInt robotCommentSizeLimit = getRobotCommentSizeLimit();
    if (robotCommentSizeLimit.isPresent()) {
      int sizeLimit = robotCommentSizeLimit.getAsInt();
      byte[] robotCommentBytes = GSON.toJson(robotCommentInput).getBytes(StandardCharsets.UTF_8);
      int robotCommentSize = robotCommentBytes.length;
      if (robotCommentSize > sizeLimit) {
        throw new BadRequestException(
            String.format(
                "Size %d (bytes) of robot comment is greater than limit %d (bytes)",
                robotCommentSize, sizeLimit));
      }
    }
  }

  private OptionalInt getRobotCommentSizeLimit() {
    int robotCommentSizeLimit =
        gerritConfig.getInt(
            "change", "robotCommentSizeLimit", DEFAULT_ROBOT_COMMENT_SIZE_LIMIT_IN_BYTES);
    if (robotCommentSizeLimit <= 0) {
      return OptionalInt.empty();
    }
    return OptionalInt.of(robotCommentSizeLimit);
  }

  private static void ensureRobotIdIsSet(String robotId, String commentPath)
      throws BadRequestException {
    if (robotId == null) {
      throw new BadRequestException(
          String.format("robotId is missing for robot comment on %s", commentPath));
    }
  }

  private static void ensureRobotRunIdIsSet(String robotRunId, String commentPath)
      throws BadRequestException {
    if (robotRunId == null) {
      throw new BadRequestException(
          String.format("robotRunId is missing for robot comment on %s", commentPath));
    }
  }

  private static void ensureFixSuggestionsAreAddable(
      List<FixSuggestionInfo> fixSuggestionInfos, String commentPath) throws BadRequestException {
    if (fixSuggestionInfos == null) {
      return;
    }

    for (FixSuggestionInfo fixSuggestionInfo : fixSuggestionInfos) {
      ensureDescriptionIsSet(commentPath, fixSuggestionInfo.description);
      ensureFixReplacementsAreAddable(commentPath, fixSuggestionInfo.replacements);
    }
  }

  private static void ensureDescriptionIsSet(String commentPath, String description)
      throws BadRequestException {
    if (description == null) {
      throw new BadRequestException(
          String.format(
              "A description is required for the suggested fix of the robot comment on %s",
              commentPath));
    }
  }

  private static void ensureFixReplacementsAreAddable(
      String commentPath, List<FixReplacementInfo> fixReplacementInfos) throws BadRequestException {
    ensureReplacementsArePresent(commentPath, fixReplacementInfos);

    for (FixReplacementInfo fixReplacementInfo : fixReplacementInfos) {
      ensureReplacementPathIsSet(commentPath, fixReplacementInfo.path);
      ensureRangeIsSet(commentPath, fixReplacementInfo.range);
      ensureRangeIsValid(commentPath, fixReplacementInfo.range);
      ensureReplacementStringIsSet(commentPath, fixReplacementInfo.replacement);
    }

    Map<String, List<FixReplacementInfo>> replacementsPerFilePath =
        fixReplacementInfos.stream().collect(groupingBy(fixReplacement -> fixReplacement.path));
    for (List<FixReplacementInfo> sameFileReplacements : replacementsPerFilePath.values()) {
      ensureRangesDoNotOverlap(commentPath, sameFileReplacements);
    }
  }

  private static void ensureReplacementsArePresent(
      String commentPath, List<FixReplacementInfo> fixReplacementInfos) throws BadRequestException {
    if (fixReplacementInfos == null || fixReplacementInfos.isEmpty()) {
      throw new BadRequestException(
          String.format(
              "At least one replacement is "
                  + "required for the suggested fix of the robot comment on %s",
              commentPath));
    }
  }

  private static void ensureReplacementPathIsSet(String commentPath, String replacementPath)
      throws BadRequestException {
    if (replacementPath == null) {
      throw new BadRequestException(
          String.format(
              "A file path must be given for the replacement of the robot comment on %s",
              commentPath));
    }
  }

  private static void ensureRangeIsSet(String commentPath, Range range) throws BadRequestException {
    if (range == null) {
      throw new BadRequestException(
          String.format(
              "A range must be given for the replacement of the robot comment on %s", commentPath));
    }
  }

  private static void ensureRangeIsValid(String commentPath, Range range)
      throws BadRequestException {
    if (range == null) {
      return;
    }
    if (!range.isValid()) {
      throw new BadRequestException(
          String.format(
              "Range (%s:%s - %s:%s) is not valid for the comment on %s",
              range.startLine,
              range.startCharacter,
              range.endLine,
              range.endCharacter,
              commentPath));
    }
  }

  private static void ensureReplacementStringIsSet(String commentPath, String replacement)
      throws BadRequestException {
    if (replacement == null) {
      throw new BadRequestException(
          String.format(
              "A content for replacement "
                  + "must be indicated for the replacement of the robot comment on %s",
              commentPath));
    }
  }

  private static void ensureRangesDoNotOverlap(
      String commentPath, List<FixReplacementInfo> fixReplacementInfos) throws BadRequestException {
    List<Range> sortedRanges =
        fixReplacementInfos
            .stream()
            .map(fixReplacementInfo -> fixReplacementInfo.range)
            .sorted()
            .collect(toList());

    int previousEndLine = 0;
    int previousOffset = -1;
    for (Range range : sortedRanges) {
      if (range.startLine < previousEndLine
          || (range.startLine == previousEndLine && range.startCharacter < previousOffset)) {
        throw new BadRequestException(
            String.format("Replacements overlap for the robot comment on %s", commentPath));
      }
      previousEndLine = range.endLine;
      previousOffset = range.endCharacter;
    }
  }

  /** Used to compare Comments with CommentInput comments. */
  @AutoValue
  abstract static class CommentSetEntry {
    private static CommentSetEntry create(
        String filename,
        int patchSetId,
        Integer line,
        Side side,
        HashCode message,
        Comment.Range range) {
      return new AutoValue_PostReview_CommentSetEntry(
          filename, patchSetId, line, side, message, range);
    }

    public static CommentSetEntry create(Comment comment) {
      return create(
          comment.key.filename,
          comment.key.patchSetId,
          comment.lineNbr,
          Side.fromShort(comment.side),
          Hashing.murmur3_128().hashString(comment.message, UTF_8),
          comment.range);
    }

    abstract String filename();

    abstract int patchSetId();

    @Nullable
    abstract Integer line();

    abstract Side side();

    abstract HashCode message();

    @Nullable
    abstract Comment.Range range();
  }

  private class Op implements BatchUpdateOp {
    private final PatchSet.Id psId;
    private final ReviewInput in;
    private final ListMultimap<RecipientType, Account.Id> accountsToNotify;

    private IdentifiedUser user;
    private ChangeNotes notes;
    private PatchSet ps;
    private ChangeMessage message;
    private List<Comment> comments = new ArrayList<>();
    private List<LabelVote> labelDelta = new ArrayList<>();
    private Map<String, Short> approvals = new HashMap<>();
    private Map<String, Short> oldApprovals = new HashMap<>();

    private Op(
        PatchSet.Id psId,
        ReviewInput in,
        ListMultimap<RecipientType, Account.Id> accountsToNotify) {
      this.psId = psId;
      this.in = in;
      this.accountsToNotify = checkNotNull(accountsToNotify);
    }

    @Override
    public boolean updateChange(ChangeContext ctx)
        throws OrmException, ResourceConflictException, UnprocessableEntityException, IOException {
      user = ctx.getIdentifiedUser();
      notes = ctx.getNotes();
      ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
      boolean dirty = false;
      dirty |= insertComments(ctx);
      dirty |= insertRobotComments(ctx);
      dirty |= updateLabels(ctx);
      dirty |= insertMessage(ctx);
      return dirty;
    }

    @Override
    public void postUpdate(Context ctx) throws OrmException {
      if (message == null) {
        return;
      }
      if (in.notify.compareTo(NotifyHandling.NONE) > 0 || !accountsToNotify.isEmpty()) {
        email
            .create(
                in.notify,
                accountsToNotify,
                notes,
                ps,
                user,
                message,
                comments,
                in.message,
                labelDelta)
            .sendAsync();
      }
      commentAdded.fire(
          notes.getChange(),
          ps,
          user.getAccount(),
          message.getMessage(),
          approvals,
          oldApprovals,
          ctx.getWhen());
    }

    private boolean insertComments(ChangeContext ctx)
        throws OrmException, UnprocessableEntityException {
      Map<String, List<CommentInput>> map = in.comments;
      if (map == null) {
        map = Collections.emptyMap();
      }

      Map<String, Comment> drafts = Collections.emptyMap();
      if (!map.isEmpty() || in.drafts != DraftHandling.KEEP) {
        if (in.drafts == DraftHandling.PUBLISH_ALL_REVISIONS) {
          drafts = changeDrafts(ctx);
        } else {
          drafts = patchSetDrafts(ctx);
        }
      }

      List<Comment> toDel = new ArrayList<>();
      List<Comment> toPublish = new ArrayList<>();

      Set<CommentSetEntry> existingIds =
          in.omitDuplicateComments ? readExistingComments(ctx) : Collections.emptySet();

      for (Map.Entry<String, List<CommentInput>> ent : map.entrySet()) {
        String path = ent.getKey();
        for (CommentInput c : ent.getValue()) {
          String parent = Url.decode(c.inReplyTo);
          Comment e = drafts.remove(Url.decode(c.id));
          if (e == null) {
            e = commentsUtil.newComment(ctx, path, psId, c.side(), c.message, c.unresolved, parent);
          } else {
            e.writtenOn = ctx.getWhen();
            e.side = c.side();
            e.message = c.message;
          }

          setCommentRevId(e, patchListCache, ctx.getChange(), ps);
          e.setLineNbrAndRange(c.line, c.range);
          e.tag = in.tag;

          if (existingIds.contains(CommentSetEntry.create(e))) {
            continue;
          }
          toPublish.add(e);
        }
      }

      switch (in.drafts) {
        case KEEP:
        default:
          break;
        case DELETE:
          toDel.addAll(drafts.values());
          break;
        case PUBLISH:
        case PUBLISH_ALL_REVISIONS:
          commentsUtil.publish(ctx, psId, drafts.values(), in.tag);
          comments.addAll(drafts.values());
          break;
      }
      ChangeUpdate u = ctx.getUpdate(psId);
      commentsUtil.deleteComments(ctx.getDb(), u, toDel);
      commentsUtil.putComments(ctx.getDb(), u, Status.PUBLISHED, toPublish);
      comments.addAll(toPublish);
      return !toDel.isEmpty() || !toPublish.isEmpty();
    }

    private boolean insertRobotComments(ChangeContext ctx) throws OrmException {
      if (in.robotComments == null) {
        return false;
      }

      List<RobotComment> newRobotComments = getNewRobotComments(ctx);
      commentsUtil.putRobotComments(ctx.getUpdate(psId), newRobotComments);
      comments.addAll(newRobotComments);
      return !newRobotComments.isEmpty();
    }

    private List<RobotComment> getNewRobotComments(ChangeContext ctx) throws OrmException {
      List<RobotComment> toAdd = new ArrayList<>(in.robotComments.size());

      Set<CommentSetEntry> existingIds =
          in.omitDuplicateComments ? readExistingRobotComments(ctx) : Collections.emptySet();

      for (Map.Entry<String, List<RobotCommentInput>> ent : in.robotComments.entrySet()) {
        String path = ent.getKey();
        for (RobotCommentInput c : ent.getValue()) {
          RobotComment e = createRobotCommentFromInput(ctx, path, c);
          if (existingIds.contains(CommentSetEntry.create(e))) {
            continue;
          }
          toAdd.add(e);
        }
      }
      return toAdd;
    }

    private RobotComment createRobotCommentFromInput(
        ChangeContext ctx, String path, RobotCommentInput robotCommentInput) throws OrmException {
      RobotComment robotComment =
          commentsUtil.newRobotComment(
              ctx,
              path,
              psId,
              robotCommentInput.side(),
              robotCommentInput.message,
              robotCommentInput.robotId,
              robotCommentInput.robotRunId);
      robotComment.parentUuid = Url.decode(robotCommentInput.inReplyTo);
      robotComment.url = robotCommentInput.url;
      robotComment.properties = robotCommentInput.properties;
      robotComment.setLineNbrAndRange(robotCommentInput.line, robotCommentInput.range);
      robotComment.tag = in.tag;
      setCommentRevId(robotComment, patchListCache, ctx.getChange(), ps);
      robotComment.fixSuggestions = createFixSuggestionsFromInput(robotCommentInput.fixSuggestions);
      return robotComment;
    }

    private List<FixSuggestion> createFixSuggestionsFromInput(
        List<FixSuggestionInfo> fixSuggestionInfos) {
      if (fixSuggestionInfos == null) {
        return Collections.emptyList();
      }

      List<FixSuggestion> fixSuggestions = new ArrayList<>(fixSuggestionInfos.size());
      for (FixSuggestionInfo fixSuggestionInfo : fixSuggestionInfos) {
        fixSuggestions.add(createFixSuggestionFromInput(fixSuggestionInfo));
      }
      return fixSuggestions;
    }

    private FixSuggestion createFixSuggestionFromInput(FixSuggestionInfo fixSuggestionInfo) {
      List<FixReplacement> fixReplacements = toFixReplacements(fixSuggestionInfo.replacements);
      String fixId = ChangeUtil.messageUuid();
      return new FixSuggestion(fixId, fixSuggestionInfo.description, fixReplacements);
    }

    private List<FixReplacement> toFixReplacements(List<FixReplacementInfo> fixReplacementInfos) {
      return fixReplacementInfos.stream().map(this::toFixReplacement).collect(toList());
    }

    private FixReplacement toFixReplacement(FixReplacementInfo fixReplacementInfo) {
      Comment.Range range = new Comment.Range(fixReplacementInfo.range);
      return new FixReplacement(fixReplacementInfo.path, range, fixReplacementInfo.replacement);
    }

    private Set<CommentSetEntry> readExistingComments(ChangeContext ctx) throws OrmException {
      return commentsUtil
          .publishedByChange(ctx.getDb(), ctx.getNotes())
          .stream()
          .map(CommentSetEntry::create)
          .collect(toSet());
    }

    private Set<CommentSetEntry> readExistingRobotComments(ChangeContext ctx) throws OrmException {
      return commentsUtil
          .robotCommentsByChange(ctx.getNotes())
          .stream()
          .map(CommentSetEntry::create)
          .collect(toSet());
    }

    private Map<String, Comment> changeDrafts(ChangeContext ctx) throws OrmException {
      Map<String, Comment> drafts = new HashMap<>();
      for (Comment c :
          commentsUtil.draftByChangeAuthor(ctx.getDb(), ctx.getNotes(), user.getAccountId())) {
        c.tag = in.tag;
        drafts.put(c.key.uuid, c);
      }
      return drafts;
    }

    private Map<String, Comment> patchSetDrafts(ChangeContext ctx) throws OrmException {
      Map<String, Comment> drafts = new HashMap<>();
      for (Comment c :
          commentsUtil.draftByPatchSetAuthor(
              ctx.getDb(), psId, user.getAccountId(), ctx.getNotes())) {
        drafts.put(c.key.uuid, c);
      }
      return drafts;
    }

    private Map<String, Short> approvalsByKey(Collection<PatchSetApproval> patchsetApprovals) {
      Map<String, Short> labels = new HashMap<>();
      for (PatchSetApproval psa : patchsetApprovals) {
        labels.put(psa.getLabel(), psa.getValue());
      }
      return labels;
    }

    private Map<String, Short> getAllApprovals(
        LabelTypes labelTypes, Map<String, Short> current, Map<String, Short> input) {
      Map<String, Short> allApprovals = new HashMap<>();
      for (LabelType lt : labelTypes.getLabelTypes()) {
        allApprovals.put(lt.getName(), (short) 0);
      }
      // set approvals to existing votes
      if (current != null) {
        allApprovals.putAll(current);
      }
      // set approvals to new votes
      if (input != null) {
        allApprovals.putAll(input);
      }
      return allApprovals;
    }

    private Map<String, Short> getPreviousApprovals(
        Map<String, Short> allApprovals, Map<String, Short> current) {
      Map<String, Short> previous = new HashMap<>();
      for (Map.Entry<String, Short> approval : allApprovals.entrySet()) {
        // assume vote is 0 if there is no vote
        if (!current.containsKey(approval.getKey())) {
          previous.put(approval.getKey(), (short) 0);
        } else {
          previous.put(approval.getKey(), current.get(approval.getKey()));
        }
      }
      return previous;
    }

    private boolean isReviewer(ChangeContext ctx) throws OrmException {
      if (ctx.getAccountId().equals(ctx.getChange().getOwner())) {
        return true;
      }
      ChangeData cd = changeDataFactory.create(db.get(), ctx.getControl());
      ReviewerSet reviewers = cd.reviewers();
      if (reviewers.byState(REVIEWER).contains(ctx.getAccountId())) {
        return true;
      }
      return false;
    }

    private boolean updateLabels(ChangeContext ctx)
        throws OrmException, ResourceConflictException, IOException {
      Map<String, Short> inLabels =
          MoreObjects.firstNonNull(in.labels, Collections.<String, Short>emptyMap());

      // If no labels were modified and change is closed, abort early.
      // This avoids trying to record a modified label caused by a user
      // losing access to a label after the change was submitted.
      if (inLabels.isEmpty() && ctx.getChange().getStatus().isClosed()) {
        return false;
      }

      List<PatchSetApproval> del = new ArrayList<>();
      List<PatchSetApproval> ups = new ArrayList<>();
      Map<String, PatchSetApproval> current = scanLabels(ctx, del);
      LabelTypes labelTypes = ctx.getControl().getLabelTypes();
      Map<String, Short> allApprovals =
          getAllApprovals(labelTypes, approvalsByKey(current.values()), inLabels);
      Map<String, Short> previous =
          getPreviousApprovals(allApprovals, approvalsByKey(current.values()));

      ChangeUpdate update = ctx.getUpdate(psId);
      for (Map.Entry<String, Short> ent : allApprovals.entrySet()) {
        String name = ent.getKey();
        LabelType lt = checkNotNull(labelTypes.byLabel(name), name);

        PatchSetApproval c = current.remove(lt.getName());
        String normName = lt.getName();
        approvals.put(normName, (short) 0);
        if (ent.getValue() == null || ent.getValue() == 0) {
          // User requested delete of this label.
          oldApprovals.put(normName, null);
          if (c != null) {
            if (c.getValue() != 0) {
              addLabelDelta(normName, (short) 0);
              oldApprovals.put(normName, previous.get(normName));
            }
            del.add(c);
            update.putApproval(normName, (short) 0);
          }
        } else if (c != null && c.getValue() != ent.getValue()) {
          c.setValue(ent.getValue());
          c.setGranted(ctx.getWhen());
          c.setTag(in.tag);
          ctx.getUser().updateRealAccountId(c::setRealAccountId);
          ups.add(c);
          addLabelDelta(normName, c.getValue());
          oldApprovals.put(normName, previous.get(normName));
          approvals.put(normName, c.getValue());
          update.putApproval(normName, ent.getValue());
        } else if (c != null && c.getValue() == ent.getValue()) {
          current.put(normName, c);
          oldApprovals.put(normName, null);
          approvals.put(normName, c.getValue());
        } else if (c == null) {
          c = ApprovalsUtil.newApproval(psId, user, lt.getLabelId(), ent.getValue(), ctx.getWhen());
          c.setTag(in.tag);
          c.setGranted(ctx.getWhen());
          ups.add(c);
          addLabelDelta(normName, c.getValue());
          oldApprovals.put(normName, previous.get(normName));
          approvals.put(normName, c.getValue());
          update.putReviewer(user.getAccountId(), REVIEWER);
          update.putApproval(normName, ent.getValue());
        }
      }

      validatePostSubmitLabels(ctx, labelTypes, previous, ups, del);

      // Return early if user is not a reviewer and not posting any labels.
      // This allows us to preserve their CC status.
      if (current.isEmpty() && del.isEmpty() && ups.isEmpty() && !isReviewer(ctx)) {
        return false;
      }

      forceCallerAsReviewer(ctx, current, ups, del);
      ctx.getDb().patchSetApprovals().delete(del);
      ctx.getDb().patchSetApprovals().upsert(ups);
      return !del.isEmpty() || !ups.isEmpty();
    }

    private void validatePostSubmitLabels(
        ChangeContext ctx,
        LabelTypes labelTypes,
        Map<String, Short> previous,
        List<PatchSetApproval> ups,
        List<PatchSetApproval> del)
        throws ResourceConflictException {
      if (ctx.getChange().getStatus().isOpen()) {
        return; // Not closed, nothing to validate.
      } else if (del.isEmpty() && ups.isEmpty()) {
        return; // No new votes.
      } else if (ctx.getChange().getStatus() != Change.Status.MERGED) {
        throw new ResourceConflictException("change is closed");
      }

      // Disallow reducing votes on any labels post-submit. This assumes the
      // high values were broadly necessary to submit, so reducing them would
      // make it possible to take a merged change and make it no longer
      // submittable.
      List<PatchSetApproval> reduced = new ArrayList<>(ups.size() + del.size());
      List<String> disallowed = new ArrayList<>(labelTypes.getLabelTypes().size());

      for (PatchSetApproval psa : del) {
        LabelType lt = checkNotNull(labelTypes.byLabel(psa.getLabel()));
        String normName = lt.getName();
        if (!lt.allowPostSubmit()) {
          disallowed.add(normName);
        }
        Short prev = previous.get(normName);
        if (prev != null && prev != 0) {
          reduced.add(psa);
        }
      }

      for (PatchSetApproval psa : ups) {
        LabelType lt = checkNotNull(labelTypes.byLabel(psa.getLabel()));
        String normName = lt.getName();
        if (!lt.allowPostSubmit()) {
          disallowed.add(normName);
        }
        Short prev = previous.get(normName);
        if (prev == null) {
          continue;
        }
        checkState(prev != psa.getValue()); // Should be filtered out above.
        if (prev > psa.getValue()) {
          reduced.add(psa);
        } else {
          // Set postSubmit bit in ReviewDb; not required for NoteDb, which sets
          // it automatically.
          psa.setPostSubmit(true);
        }
      }

      if (!disallowed.isEmpty()) {
        throw new ResourceConflictException(
            "Voting on labels disallowed after submit: "
                + disallowed.stream().distinct().sorted().collect(joining(", ")));
      }
      if (!reduced.isEmpty()) {
        throw new ResourceConflictException(
            "Cannot reduce vote on labels for closed change: "
                + reduced
                    .stream()
                    .map(p -> p.getLabel())
                    .distinct()
                    .sorted()
                    .collect(joining(", ")));
      }
    }

    private void forceCallerAsReviewer(
        ChangeContext ctx,
        Map<String, PatchSetApproval> current,
        List<PatchSetApproval> ups,
        List<PatchSetApproval> del) {
      if (current.isEmpty() && ups.isEmpty()) {
        // TODO Find another way to link reviewers to changes.
        if (del.isEmpty()) {
          // If no existing label is being set to 0, hack in the caller
          // as a reviewer by picking the first server-wide LabelType.
          LabelId labelId = ctx.getControl().getLabelTypes().getLabelTypes().get(0).getLabelId();
          PatchSetApproval c = ApprovalsUtil.newApproval(psId, user, labelId, 0, ctx.getWhen());
          c.setTag(in.tag);
          c.setGranted(ctx.getWhen());
          ups.add(c);
        } else {
          // Pick a random label that is about to be deleted and keep it.
          Iterator<PatchSetApproval> i = del.iterator();
          PatchSetApproval c = i.next();
          c.setValue((short) 0);
          c.setGranted(ctx.getWhen());
          i.remove();
          ups.add(c);
        }
      }
      ctx.getUpdate(ctx.getChange().currentPatchSetId()).putReviewer(user.getAccountId(), REVIEWER);
    }

    private Map<String, PatchSetApproval> scanLabels(ChangeContext ctx, List<PatchSetApproval> del)
        throws OrmException, IOException {
      LabelTypes labelTypes = ctx.getControl().getLabelTypes();
      Map<String, PatchSetApproval> current = new HashMap<>();

      for (PatchSetApproval a :
          approvalsUtil.byPatchSetUser(
              ctx.getDb(),
              ctx.getControl(),
              psId,
              user.getAccountId(),
              ctx.getRevWalk(),
              ctx.getRepoView().getConfig())) {
        if (a.isLegacySubmit()) {
          continue;
        }

        LabelType lt = labelTypes.byLabel(a.getLabelId());
        if (lt != null) {
          current.put(lt.getName(), a);
        } else {
          del.add(a);
        }
      }
      return current;
    }

    private boolean insertMessage(ChangeContext ctx) throws OrmException {
      String msg = Strings.nullToEmpty(in.message).trim();

      StringBuilder buf = new StringBuilder();
      for (LabelVote d : labelDelta) {
        buf.append(" ").append(d.format());
      }
      if (comments.size() == 1) {
        buf.append("\n\n(1 comment)");
      } else if (comments.size() > 1) {
        buf.append(String.format("\n\n(%d comments)", comments.size()));
      }
      if (!msg.isEmpty()) {
        buf.append("\n\n").append(msg);
      } else if (in.ready) {
        buf.append("\n\n" + START_REVIEW_MESSAGE);
      }
      if (buf.length() == 0) {
        return false;
      }

      message =
          ChangeMessagesUtil.newMessage(
              psId, user, ctx.getWhen(), "Patch Set " + psId.get() + ":" + buf, in.tag);
      cmUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(psId), message);
      return true;
    }

    private void addLabelDelta(String name, short value) {
      labelDelta.add(LabelVote.create(name, value));
    }
  }
}
